
先看一行 log。
speculative decode stats iterations=57 drafted=108 accepted=70
acceptance=0.65 avg_draft=1.89 avg_accepted=1.23
acceptance=0.65。我第一眼看到也想:六成五,至少過半,應該有賺。
結果把同一台機器、同一顆模型的八筆紀錄攤開,最準的那一筆,每輪只拿回 2.37 個 token;接受率 0.940 的那筆是 3.76。滿分那筆低了 37%。
| # | iterations | drafted | accepted | 接受率 | 每輪 target 產出 |
|---|---|---|---|---|---|
| 3 | 39 | 93 | 41 | 0.441 | 2.05 |
| 4 | 36 | 76 | 44 | 0.579 | 2.22 |
| 1 | 57 | 108 | 70 | 0.648 | 2.23 |
| 5 | 19 | 51 | 45 | 0.882 | 3.37 |
| 8 | 20 | 47 | 44 | 0.936 | 3.20 |
| 6 | 18 | 49 | 46 | 0.939 | 3.56 |
| 7 | 17 | 50 | 47 | 0.940 | 3.76 |
| 2 | 27 | 37 | 37 | 1.000 | 2.37 |
【T1・Ollama MLX runner・qwen3.8:27b-mlx・2026-08-27 與 08-30 兩段 session 的全部八筆。】
(「每輪 target 產出」= 每次 target decode step 平均拿回幾個 token,包含沒有草稿、退回普通 decode 的那些輪。)
依接受率由低到高排,最後一列直接掉下來。這不代表它一定跑得最慢——表上沒有時間——但已經夠說明接受率和每輪換回幾個不是同一件事。
一個便宜的來源先猜幾個 token,主模型用一次 batched forward 把這幾個候選一起驗完,可能一次就確定好幾個。這就是它能突破 Day 11 那條「一步一顆 token」上界的地方。
一輪結束你拿到的是 被接受的草稿數 + 1。那個 +1 是主模型自己確定的,不是猜的——所以就算一個草稿都沒中,那一輪也一定產出 1 個 token。
**產出有地板,但成本沒有。**猜它、驗它的時間已經付掉了,整體速度照樣可能倒退。這篇後面就有一格倒退到 0.52×。
回頭看那兩筆。第 2 筆平均只猜 1.37 個,全中也只換回 1.37 + 1 = 2.37;第 7 筆猜 2.94 個、中了 94%,換回 2.76 + 1 = 3.76。猜得準但猜得少,輸給猜得多但沒那麼準。
所以要分三層看:接受率量準度,分母是猜了幾個;每輪 target 產出量收益,分母是跑了幾輪;實測加速比才是扣完成本的淨利——每輪 target 產出還得除以這一輪花了多久。
這裡有一個很容易踩到的口徑坑。llama.cpp 的 server log 會印 mean len,vLLM 也能回傳 mean_acceptance_length,兩者都是 1 + 被接受的草稿數 ÷ speculative verification 次數。
但本文算的是「每次 target step 拿回幾個 token」,分母包含 n-gram 找不到候選、只能普通 decode 的那些輪;官方數字只看真的產生過草稿的輪。**一個在問「有猜的時候拿回多少」,另一個在問「整段生成平均拿回多少」。**對 n-gram 來說兩者可能差很多——拿不同工具的數字互相比之前,先看分母。

圖 1:同一台機器、同一顆模型的八筆紀錄,橫軸是工具印給你的數字。每輪 target 產出 = (iterations + accepted) ÷ iterations;那個 +1 是主模型額外確定的 token——有草稿被拒絕時是 correction token,全數接受時是 bonus token,這一輪根本沒有草稿時就是普通 target token。八個點不足以宣稱任何函數關係,所以不畫趨勢線。
我固定 target、prompt、輸出長度、sampling 與硬體,只替換 drafter,讓兩條路線對打。
target 是 Llama-3.1-8B Q4_K_M。一邊是 ngram-mod,不用第二個權重檔,從已生成的序列裡撈候選,維護一張約 16 MB 的雜湊表;另一邊是 draft-simple 配一顆 Llama-3.2-1B Q4_K_M,每猜一個 token 就跑一次它的 forward(這裡把它當 generic small-model drafter 的成本對照,不代表專門訓練的 draft model/head 的表現)。
| drafter | 接受率 | 每輪 target 產出 | 加速比 |
|---|---|---|---|
ngram-mod(--spec-default 即為此) |
1.000 | 14.22 | 6.43× |
draft-simple + 1B(--spec-draft-n-max 7) |
0.998 | 7.88 | 1.12× |
【T1 M4 Max 128GB・llama.cpp b10488・llama-server・-ngl 99 -fa off -c 4096 -np 1・n_predict 512・temperature 0・cache_prompt false・一格一個獨立 process・3 次取中位數。ngram 側用預設的 --spec-ngram-mod-n-match 24 / n-min 48 / n-max 64。同一格五次重跑的全距比達 1.261×,所以這組資料無法把 1.12× 與執行波動分開;6.43× 則遠超波動範圍。】
接受率只差 0.2%,兩邊都幾乎猜什麼中什麼。可是一輪結帳,ngram 拿回 14.22 個 token,1B 只有 7.88;前者主要付的是雜湊表的查找與維護,後者則得逐個 token 跑小模型。這一局,ngram 是一邊拿得多,一邊付得少。
所以帳要這樣記:
加速比 = 每輪 target 產出 × 不開投機時每個 token 的耗時
÷ 開了投機之後每一輪的總耗時
分子是收益,分母是成本。--spec-draft-n-max 7 表示每輪最多猜七個——猜滿七個就得付七次 1B forward,提早停下來才會少一些。這裡只能比檔案大小:1B 的 GGUF 是 0.75 GiB、8B 是 4.58 GiB,比例 16.4%。那不等於單次 forward 的耗時比,只說明 drafter 並不免費。

圖 2:一輪裡的三個量各自算什麼。加速比的分母是 drafter 猜的時間 + 一次 batched verification + dispatch、同步、KV 維護——這篇沒有把三項拆開量。⚠️ 另外,工具印的 mean len 分母只算有草稿的輪,跟圖上這個「每輪 target 產出」不同,兩者不能直接互換。
第 11 天我算過 M4 Max 跑 70B 的 plain-decode 上界是 546 ÷ 42.52 ≈ 12.8 tok/s,並且寫「投機有能力打破它,但我沒有任何證據」。
我原本想直接用 70B 補上這筆證據,但同一格四次跑出 7.74~15.83 tok/s,波動整整兩倍,整組只能作廢。所以下面改用同一輪量測穩定得多的 12B——它證明的是那道上界的前提能被打破,不是一份 70B 跑分。
Gemma 4 12B QAT Q4_0 的檔案是 6.976 GB,同一條算式給 78.27 tok/s。
| workload | 不開投機(tok/s) | 佔上界 | 開 ngram-mod(tok/s) |
佔上界 |
|---|---|---|---|---|
| 逐字重複 | 50.24 | 64.2% | 96.44 | 123.2% |
| 照文件摘錄 | 48.63 | 62.1% | 75.50 | 96.5% |
| 中文散文 | 50.00 | 63.9% | 76.92 | 98.3% |
| 寫程式 | 49.25 | 62.9% | 71.60 | 91.5% |
【同上條件,n_predict 128,每格 3 次取中位數,八格全距比 1.006–1.116×。】
逐字重複那格是帳面上界的 123%。上界沒有壞——它算的是一步讀一次權重,而投機讓一次 target verification 換回好幾個 token。破的是前提,不是物理。
對 draft-simple 來說,--spec-draft-n-max 決定每輪最多猜幾個,llama.cpp 的預設是 3。
同一顆 8B 配那顆 1B,跑一段中文散文、輸出 128 個 token:0.52×。開了投機,慢了將近一半。接受率只有 0.481,猜十個中不到五個,而每一次猜測不管中不中,1B 的 forward 都已經付出去了。
【同上條件,n_predict 128、--spec-draft-n-max 3(llama.cpp 預設),每格 4 次取中位數。這格基準線本身不算穩,四次落在 33.98–50.34 tok/s;但開了投機的四次是 21.04–25.23,兩組區間完全不重疊——就算拿基準線最差的一次去比投機最好的一次,也還是 0.74×。】
但同一格把輸出拉到 512,就只剩 1.02×、接受率升到 0.818。慢一半那件事,綁在 128 token 的窗上。
同一段散文換成 ngram 也一樣:128 個 token 時一個候選都沒產生,512 個才接受 196/400——它要先累積到夠長的重複片段才開始有用。兩件事指向同一條:你量到的「有沒有效」,可能只是你選的輸出長度。
想自己試,只需要一台跑得動 llama-server 的機器和現成的 log;不必先下載第二顆模型,--spec-default 就能啟用 ngram-mod。
三步:確認這次真的有草稿、算出每輪 target 產出、再跟關掉投機的對照。細節在圖裡。

圖 3:三步各一行,可以截圖帶走。N 是產出的 token 總數,accepted 是被接受的草稿數——llama.cpp 給 predicted_n 與 draft_n_accepted,Ollama 給 iterations 與 accepted。第三步要起兩個 process,是因為 --spec-type 是 server 的啟動參數,不是打兩次 API。
圖上沒寫的兩個例外。沒看到 draft_n 不等於沒啟用——也可能是啟用了但 n-gram 一個候選都沒找到,上一節那格就是後者。另外,在我這版 Ollama 上,GGUF runner 印的是 no implementations specified for speculative decoding,MLX runner 才印本文開頭那行 stats。
一個可以自己驗的事:在 llama.cpp b10488、temperature=0、本文那四組 ngram-mod 對照裡,開與不開投機的四組輸出逐字相同,sha256 也一致。
⚠️ 但這四組不能替所有 drafter 背書。標準投機解碼理論上保持 target 的輸出分布,而上游有人回報:量化 target 配 draft-mtp / draft-dspark 時即使 greedy 也會偏離,同一顆量化 target 換成 ngram 則一致【ggml-org/llama.cpp#25618,2026-08 仍 open、尚未確認】。「無損」要綁 drafter 路線、量化格式與 runtime 一起講。
| 方法 | 要不要第二個權重檔 | 候選從哪來 |
|---|---|---|
ngram-simple |
不要 | 當前 token history 裡重複出現的片段 |
ngram-map-k / ngram-map-k4v |
不要 | 同上,不同索引與統計結構 |
ngram-mod(--spec-default 開的就是它) |
不要 | token history + 一張共用的 hash pool(約 16 MB) |
ngram-cache |
不要 | 累積的 n-gram 統計,也可載入外部統計檔 |
draft-simple |
要 | 另一顆小模型 |
draft-eagle3 / draft-dflash |
要 | 針對 target 訓練的 draft model/head |
draft-dspark |
要 | DFlash backbone + semi-autoregressive Markov head |
draft-mtp |
看 target 自己有沒有 | 模型內建的 MTP head |
draft-dspark 要一份針對該 target 訓練的 checkpoint,而且官方文件寫明目前只支援 Qwen3 backbone,其他仍在規劃中。
mean len 分母只算有草稿的輪,跟這裡不同。明天 Day 21 換一張帳:一張 12GB 卡要同時養 LLM、embedding、reranker 三張嘴,顯存怎麼一毛一毛分——那本帳裡最容易被漏掉的,是三份沒人記得的 CUDA context。
咱們明天見。